Web Locks相關的API目前還是實驗性質的,這意味著未來可能有所變動,會與本片內容提及用法、作用有差異。雖然是實驗性質,但目前主流瀏覽器都已經支援。
最基本用法是透過navigator.locks.request()
取得一把鎖,如果無法取得就必須等待直到能夠取得。如果取得了,就可以執行後續callback的動作。通常callback是一個異步函式,舉例來說寫法會如下:
navigator.locks.request('lock-1', async (lock) => {
console.log('get lock-1');
console.log('do something');
console.log('release lock-1');
});
callback的執行區域,被稱作是 關鍵區域 (Critical section)。
如果設計的恰當,關鍵區域只會有一個在執行。把上面再改寫一下:
var lock_name = 'lock-1';
navigator.locks.request(lock_name, (lock) => {
console.log(`A: get lock ${lock.name}`);
return new Promise(res => {
/// 10秒後釋放鎖
setTimeout(() => {
console.log(`A: release lock ${lock.name}`);
res(); // release lock
}, 10000 /*ms*/);
})
})
navigator.locks.request(lock_name, (lock) => {
console.log(`B: get lock ${lock.name}`);
return new Promise(res => {
/// 5秒後釋放鎖
setTimeout(() => {
console.log(`B: release lock ${lock.name}`);
res(); // release lock
}, 5000 /*ms*/);
})
})
A: get lock lock-1
A: release lock lock-1
B: get lock lock-1
B: release lock lock-1
在上面範例,有兩個程式區塊A和B需要使用到lock-1
這把鎖。A需要消耗10秒,並優先取得了鎖;B必須等待10秒後,才會開始執行。
可以透過將Promise
的resolve()
或reject()
傳遞出來,來決定什麼時候要釋放鎖:
var resolve, reject;
var p = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
callback會改成:
navigator.locks.request('lock-1', (lock) => {
console.log(`get lock ${lock.name}`);
p.then(_ => console.log(`release lock ${lock.name}`))
return p;
})
你可自行決定何時應該呼叫resolve()
或reject()
。
至於這究竟有什麼作用?通常這樣的設計在支援多執行緒的程式語言裡,算是蠻常見的。在關鍵區域裡,操作一個共用資料,可以避免因為中斷和執行順序的不確定,造成共用資料的不可預測。比如有兩段同時執行的程式的執行邏輯如下:
A
,並先儲存於暫時的函式變數tmp
A
值設為tmp + 1
試問A
最後結果為何?
var A = 1;
function fn(name) {
var tmp = A; // 取得共用變數`A`,並先儲存於暫時的函式變數`tmp`
var wait = Math.random() * 5 * 1000; /*seconds*/
var updateA = () => {
A = tmp + 1;
console.log(`finish ${name}`);
};
setTimeout(updateA, wait);
}
fn('甲');
fn('乙');
console.log(A)
下面兩張GIF執行的程式片段一模一樣,只是時間上略有些差異,但造成A
的結果卻截然不同:
可以加上WebLock以保證其實行順序:
var A = 1;
function fn(name) {
var resolve = null;
var promise = new Promise(res => resolve = res);
navigator.locks.request("A", (lock) => { // lock
console.log(`${name} get lock A`)
var tmp = A; // 取得共用變數`A`,並先儲存於暫時的函式變數`tmp`
var wait = Math.random() * 5 * 1000; /*seconds*/
var updateA = () => {
A = tmp + 1;
console.log(`finish ${name}`);
resolve(); // unlock
};
setTimeout(updateA, wait);
return promise;
});
}
(() => fn('甲'))();
(() => fn('乙'))();
console.log(A);
await navigator.locks.query('A');
不過瀏覽器主頁面執行環境其實是單執行緒的,就算異步問題有時候非常難處理,但還是有跡可循。不過並不是沒有多執行緒的情況,在使用Web Worker
、Service Worker
已經多頁籤的情況下就會有多執行緒資源競爭的問題。但哪些資源會互相共用競爭?以下我列出幾個我目前想到的:
這些是目前想到的,同一個網站,甚至同一個網域或子網域有可能共同存取乃至寫入的資源,這些資源就有可能造成資源競爭。我不太確定SessionStorage
又會在哪些情況下與頁面共用,但它也可能與Worker共用。
除此之外,它也有可能阻止瀏覽器進入睡眠或凍結。已Microsoft Edge瀏覽器來說,當頁籤進入背景或視窗縮到最小,就有可能暫停運作。有一些方式可以避免暫停的行為,使用Web Locks可能就是一種處理方式:
瀏覽器睡眠索引標籤
某些瀏覽器具有索引標籤凍結或睡眠功能,以減少非使用中索引標籤的電腦資源使用量。 這可能會導致 SignalR 連線關閉,而且可能會導致不必要的使用者體驗。 瀏覽器會使用啟發學習法來找出索引標籤是否應該進入睡眠狀態,例如:
- 播放音訊
- 保留 Web 鎖定
- IndexedDB按住鎖定
- 連線到 USB 裝置
- 擷取視訊或音訊
- 正在鏡像
- 擷取視窗或顯示
瀏覽器啟發學習法可能會隨著時間而變更,而且瀏覽器之間可能會有所不同。 檢查支援矩陣,並找出最適合您案例的方法。為了避免讓應用程式進入睡眠狀態,應用程式應該觸發瀏覽器使用的其中一個啟發學習法。
下列程式碼範例示範如何使用 Web 鎖定 讓索引標籤保持喚醒,並避免非預期的連線關閉。
var lockResolver; if (navigator && navigator.locks && navigator.locks.request) { const promise = new Promise((res) => { lockResolver = res; }); navigator.locks.request('unique_lock_name', { mode: "shared" }, () => { return promise; }); }
針對上述程式碼範例:
- *Web 鎖定是實驗性的。 條件式檢查會確認瀏覽器支援 Web 鎖定。
- 承諾解析程式 lockResolver 會儲存,以便在索引標籤可接受進入睡眠狀態時釋放鎖定。
- 關閉連線時,會藉由呼叫 lockResolver() 來釋放鎖定。 釋放鎖定時,允許索引標籤進入睡眠狀態。
當只做讀取而不會改變資料狀態,這時若是每次存取都需要先取得鎖這樣就很麻煩。因此其實可以實現讀寫鎖:寫入的時候只允許一個使用,並且不能有其他在讀取,必須等待讀取結束;讀取時則允許多個同時讀取。
var A = 1;
function readA(id) {
var resolve = null;
var promise = new Promise(res => resolve = res);
navigator.locks.request("A", {mode: 'shared'}, (lock) => { // shared lock
console.log(`${id} get lock A`)
return promise;
});
return {data: A, resolve};
}
function writeA(id, newData) {
var resolve = null;
var promise = new Promise(res => resolve = res);
navigator.locks.request("A", {mode: 'exclusive'}, (lock) => { // shared lock
console.log(`${id} get lock A`)
A = newData;
return promise;
});
return resolve;
}
透過指定選項mode
是shared
或exclusive
,可以實現讀寫鎖。首先先看看先有讀取者的情況,若有需要寫入,則必須等待所有讀取完成:
var readers = [];
for(let i = 1; i <= 10; i ++) {
readers.push(readA(i));
}
var writer = writeA("writer", 2); // 必須等待所有讀者結束動作
但如果有需要等待的寫入動作,就不能立刻再執行新的讀取需求的同樣必須等待。
var writer = writeA("writer", 2); // 必須等待所有讀者結束動作
var r11 = readA(11); // 有需要寫入的情況在,禁止新的讀者
readers.forEach(r => r.resolve()); //全部閱讀完畢
// 寫入資料
等先前的讀取結束後,寫入會嘗試取得鎖,並執行寫入動作。這時候如果有新的讀取需求也依然需要等待寫入結束:
// 寫入未完成,禁止其他讀取
writer(); // 寫入資料完畢。 `r11`可以進行讀取
r11.resolve();
也可以添加選項ifAvailable
,如果無法取得鎖就直接放棄動作:
function tryUpdateA(id, newData) {
var resolve = null;
var promise = new Promise(res => resolve = res);
navigator.locks.request("A", {ifAvailable: true}, (lock) => { // shared lock
if(lock == null) // get lock fail
return promise
console.log(`${id} get lock A`)
A = newData;
return promise;
});
return resolve;
}
還可以傳入一個signal
選項用來實現超時:
function updateA(id, newData, timeout /*seconds*/) {
var resolve = null;
var promise = new Promise(res => resolve = res);
var controller = new AbortController();
var signal = controller.signal;
setTimeout(() => controller.abort('timeout'), timeout * 1000)
navigator.locks.request("A", {signal}, (lock) => { // shared lock
console.log(`${id} get lock A`)
A = newData;
return promise;
});
return resolve;
}
為了比較好操作,可以把controller
傳出來:
function updateA2(id, newData) {
var resolve = null;
var promise = new Promise(res => resolve = res);
var controller = new AbortController();
var signal = controller.signal;
navigator.locks.request("A", {signal}, (lock) => { // shared lock
console.log(`${id} get lock A`)
A = newData;
return promise;
});
return {resolve, controller};
}
這麼一來可以手動決定是不是要放棄:
var updator2 = updateA2('up2', 101)
query()
)透過query()
可以查詢取得鎖和等待鎖的數量:
console.log(await navigator.locks.query("A"));
這個可能對於很多人並不是很好理解,就算是有過多執行緒平行處理撰寫經驗的也是一樣,可能在理解使用方式上會有一些誤會。
除此之外,像這種資料競爭問題,一樣會遇到像是死鎖或飢餓的問題。最經典的例子就是「哲學家就餐問題」。
最後提醒一下,這個API仍處在實驗階段。未來仍有可能被廢棄或改變用法,使用上應多加留意
本文同時發表於我的隨筆